﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using System.Xml.Serialization;
using System.Net;
using Flipmind.Xero.Internal;
using System.Xml;

namespace Flipmind.Xero
{
    /// <summary>
    /// Provides methods to access the Xero API
    /// </summary>
    public class XeroAPI
    {
        /// <summary>
        /// The URL of the Xero API in the form <scheme>://<host>
        /// </summary>
        private string apiUrl;
        /// <summary>
        /// The Xero API key
        /// </summary>
        private string apiKey;
        /// <summary>
        /// The key for the customer we are fetching/updating data for
        /// </summary>
        private string customerKey;
        /// <summary>
        /// The part of the URL that identifies the version of the API being called
        /// </summary>
        private static readonly string API_VERSION_URL = "/api.xro/1.0/";
        /// <summary>
        /// The format of dates/times that Xero needs
        /// </summary>
        private static readonly string XERO_DT_FORMAT = "yyyy-MM-ddTHH:mm:ss.ttZ";
        /// <summary>
        /// Should basic checks be made against contact and invoices before calling Xero to catch
        /// common errors
        /// </summary>
        private bool validate = true;

        /// <summary>
        /// Creates a new instance to allow the Xero API to be called
        /// </summary>
        /// <param name="apiUrl">The URL of the Xero API in the form <scheme>://<host></param>
        /// <param name="apiKey">The Xero API key</param>
        /// <param name="customerKey">The key for the customer we are fetching/updating data for</param>
        public XeroAPI(string apiUrl, string apiKey, string customerKey)
        {
            this.apiUrl = apiUrl;
            this.apiKey = apiKey;
            this.customerKey = customerKey;
        }

        /// <summary>
        /// Creates a new instance to allow the Xero API to be called
        /// </summary>
        /// <param name="apiUrl">The URL of the Xero API in the form <scheme>://<host></param>
        /// <param name="apiKey">The Xero API key</param>
        /// <param name="customerKey">The key for the customer we are fetching/updating data for</param>
        /// <param name="validate">True if checks on required fields and that invoices add up should be made before calling Xero</param>
        public XeroAPI(string apiUrl, string apiKey, string customerKey, bool validate)
        {
            this.apiUrl = apiUrl;
            this.apiKey = apiKey;
            this.customerKey = customerKey;
            this.validate = validate;
        }

        /// <summary>
        /// Should we validate invoices before sending them to Xero
        /// </summary>
        public bool Validate
        {
            get
            {
                return this.validate;
            }
            set
            {
                this.validate = value;
            }
        }
        
        /// <summary>
        /// Gets the contact with the given contact ID
        /// </summary>
        /// <param name="contactId">The ID of the contact to fetch</param>
        /// <returns></returns>
        public Contact GetContactById(string contactID)
        {
            string url = apiUrl + API_VERSION_URL + "contact?" + GetBaseUrlParameters() + "&contactID=" + contactID;
            GetContactResponse res = (GetContactResponse)GetObjectFromURL(typeof(GetContactResponse), url);
            return res.Contact;            
        }


        /// <summary>
        /// Gets the contacts of the given type
        /// </summary>
        /// <param name="contactType"></param>
        /// <returns></returns>
        public Contact[] GetContactByType(string contactType)
        {
            string url = apiUrl + API_VERSION_URL + "contacts?" + GetBaseUrlParameters() + "&type=" + contactType;
            GetContactsResponse res = (GetContactsResponse)GetObjectFromURL(typeof(GetContactsResponse), url);
            return res.Contacts;
        }

        /// <summary>
        /// Searches the Xero contact database for the customer
        /// </summary>
        /// <param name="criteria">Specifies the details of the search</param>
        /// <returns></returns>
        public Contact[] SearchContacts(ContactSearchCriteria criteria)
        {
            StringBuilder sbUrl = new StringBuilder();
            sbUrl.Append(apiUrl);
            sbUrl.Append(API_VERSION_URL);
            sbUrl.Append("contacts?");
            sbUrl.Append(GetBaseUrlParameters());
            if (criteria.ContactType != SearchContactType.All)
            {
                sbUrl.Append("&type=");
                if (criteria.ContactType == SearchContactType.Customers)
                {
                    sbUrl.Append("customers");
                }
                else
                {
                    sbUrl.Append("suppliers");
                }
            }
            if (criteria.SortBy == SearchContactSortBy.Name)
            {
                sbUrl.Append("&sortBy=name");
            }
            else if (criteria.SortBy == SearchContactSortBy.EmailAddress)
            {
                sbUrl.Append("&sortBy=emailaddress");
            }
            if (criteria.SortDirection == SearchContactSortByDirection.Asc)
            {
                sbUrl.Append("&direction=asc");
            }
            else
            {
                sbUrl.Append("&direction=desc");
            }
            if (criteria.UpdatedAfter != DateTime.MinValue)
            {
                sbUrl.Append("&updatedAfter=");
                sbUrl.Append(criteria.UpdatedAfter.ToUniversalTime().ToString(XERO_DT_FORMAT));
            }
            GetContactsResponse res = (GetContactsResponse)GetObjectFromURL(typeof(GetContactsResponse), sbUrl.ToString());
            return res.Contacts;
        }

        /// <summary>
        /// Saves (inserts or update if contact number is different) and returns the result
        /// </summary>
        /// <param name="contact">The contact to save</param>
        /// <returns>The contact saved with the GUID populated</returns>
        public Contact SaveContact(Contact contact)
        {
            string url = apiUrl + API_VERSION_URL + "contact?" + GetBaseUrlParameters();
            GetContactResponse res = (GetContactResponse)PutObject(contact, url, typeof(GetContactResponse),null);
            ValidateContact(contact);
            CheckResponse(res);
            return res.Contact;
        }

        /// <summary>
        /// Saves (inserts or update if contact number is different) and returns the result
        /// </summary>
        /// <param name="contact">The contact to save</param>
        /// <returns>The contact saved with the GUID populated</returns>
        public Contact[] SaveContact(Contact[] contacts)
        {
            string url = apiUrl + API_VERSION_URL + "contacts?" + GetBaseUrlParameters();
            foreach (Contact contact in contacts)
            {
                ValidateContact(contact);
            }
            SaveContactsRequest req = new SaveContactsRequest();
            req.Contacts = contacts;
            GetContactsResponse res = (GetContactsResponse)PutObject(req, url, typeof(GetContactsResponse),null);
            CheckResponse(res);
            return res.Contacts;
        }

        /// <summary>
        /// Gets the invoice with the given ID
        /// </summary>
        /// <param name="invoiceID"></param>
        /// <returns></returns>
        public Invoice GetInvoiceById(string invoiceID)
        {
            string url = apiUrl + API_VERSION_URL + "invoice?" + GetBaseUrlParameters() + "&invoiceID=" + invoiceID;
            GetInvoiceResponse res = (GetInvoiceResponse)GetObjectFromURL(typeof(GetInvoiceResponse), url);
            return res.Invoice;            
        }
                
        /// <summary>
        /// Gets all invoices for the customer
        /// </summary>
        /// <returns></returns>
        public Invoice[] GetInvoices()
        {
            string url = apiUrl + API_VERSION_URL + "invoices?" + GetBaseUrlParameters();
            GetInvoicesResponse res = (GetInvoicesResponse)GetObjectFromURL(typeof(GetInvoicesResponse), url);
            return res.Invoices;  
        }

        /// <summary>
        /// Gets all invoices modified after the given time
        /// </summary>
        /// <param name="modifiedAfter">The time that the invoices to be returned should be modified after</param>
        /// <returns></returns>
        public Invoice[] GetInvoiceModifiedAfter(DateTime modifiedAfter)
        {
            string url = apiUrl + API_VERSION_URL + "invoices?" + GetBaseUrlParameters() + "&modifiedSince=" + modifiedAfter.ToUniversalTime().ToString(XERO_DT_FORMAT);
            GetInvoicesResponse res = (GetInvoicesResponse)GetObjectFromURL(typeof(GetInvoicesResponse), url);
            return res.Invoices;
        }

        /// <summary>
        /// Creates in the invoices within Xero
        /// </summary>
        /// <param name="invoices">Creates the invoices within Xero</param>
        /// <returns>The created invoice</returns>
        public Invoice[] CreateInvoices(Invoice[] invoices)
        {
            string url = apiUrl + API_VERSION_URL + "invoices?" + GetBaseUrlParameters();
            PutInvoicesRequest req = new PutInvoicesRequest();
            foreach (Invoice inv in invoices)
            {
                ValidateAndCorrectInvoice(inv);
            }
            req.Invoices = invoices;
            GetInvoicesResponse res = (GetInvoicesResponse)PutObject(req, url, typeof(GetInvoicesResponse), new string[] { "UpdatedDateUTC", "InvoiceStatus", "InvoiceID", "ContactStatus" });
            CheckResponse(res);
            return res.Invoices;
        }

        /// <summary>
        /// Creates the invoice within Xero
        /// </summary>
        /// <param name="invoice">The created invoice</param>
        /// <returns></returns>
        public Invoice CreateInvoice(Invoice invoice)
        {
            string url = apiUrl + API_VERSION_URL + "invoice?" + GetBaseUrlParameters();
            ValidateAndCorrectInvoice(invoice);
            GetInvoiceResponse res = (GetInvoiceResponse)PutObject(invoice, url, typeof(GetInvoiceResponse), new string[] { "UpdatedDateUTC", "InvoiceStatus", "InvoiceID", "ContactStatus" });
            CheckResponse(res);
            return res.Invoice;
        }


        /// <summary>
        /// Gets all the tracking categories in Xero
        /// </summary>
        /// <returns></returns>
        public TrackingCategory[] GetTracking()
        {
            string url = apiUrl + API_VERSION_URL + "tracking?" + GetBaseUrlParameters();
            TrackingResponse res = (TrackingResponse)GetObjectFromURL(typeof(TrackingResponse), url);
            return res.Tracking;
        }

        /// <summary>
        /// Gets all the Accounts in Xero
        /// </summary>
        /// <returns></returns>
        public Account[] GetAccounts()
        {
            string url = apiUrl + API_VERSION_URL + "accounts?" + GetBaseUrlParameters();
            GetAccountsResponse res = (GetAccountsResponse)GetObjectFromURL(typeof(GetAccountsResponse), url);
            return res.Accounts;
        }

        /// <summary>
        /// Checks that required fields of a contact are populated
        /// </summary>
        /// <param name="contact"></param>
        private void ValidateContact(Contact contact)
        {
            if (validate)
            {
                if (string.IsNullOrEmpty(contact.Name))
                {
                    throw new Exception("The contact Name must be populated");
                }
            }
        }

        /// <summary>
        /// Validates the invoice before sending it to Xero
        /// </summary>
        /// <param name="invoice"></param>
        private void ValidateAndCorrectInvoice(Invoice invoice)
        {
            decimal totalTax = 0;
            if (validate)
            {
                if (invoice.Total != (invoice.TotalTax + invoice.SubTotal))
                {
                    throw new Exception("Invoice total does not equal subtotal+totaltax");
                }
            }
            foreach (InvoiceLineItem line in invoice.LineItems)
            {
                if (line.Tracking == null)
                {// HACK.  Xero will fail with odd errors if Tracking element missing
                    line.Tracking = new InvoiceLineItemTrackingCategory();
                }
                if (validate)
                {
                    decimal lineTotal = line.UnitAmount * line.Quantity;
                    if (lineTotal != line.LineAmount)
                    {
                        throw new Exception("Line total does not equal unit amount * quantity");
                    }
                    totalTax += line.TaxAmount;
                }
            }
            if (validate)
            {
                if (totalTax != invoice.TotalTax)
                {
                    throw new Exception("Line item tax does not match invoice total tax");
                }
            }
        }

        /// <summary>
        /// Creates a HttpWebRequest to call the web pages at Xero.
        /// </summary>
        /// <param name="url"></param>
        /// <param name="method"></param>
        /// <returns></returns>
        private HttpWebRequest CreateWebRequest(string url, string method)
        {
            HttpWebRequest request = WebRequest.Create(url) as HttpWebRequest;
            request.UserAgent = "Xero API Client";
            request.KeepAlive = false;
            request.Method = method;
            request.ContentType = "application/x-www-form-urlencoded";
            // set the timeout to 120 seconds
            request.Timeout = 120 * 1000;
            return request;
        }

        /// <summary>
        /// Serializes an object into an Xml Document
        /// </summary>
        /// <param name="o">The object to serialize</param>
        /// <returns>An Xml Document consisting of said object's data</returns>
        private static XmlDocument Serialize(object o)
        {
            XmlSerializer s = new XmlSerializer(o.GetType());

            MemoryStream ms = new MemoryStream();
            XmlTextWriter writer = new XmlTextWriter(ms, new UTF8Encoding());
            writer.Formatting = Formatting.Indented;
            writer.IndentChar = ' ';
            writer.Indentation = 5;
            Exception caught = null;

            try
            {
                s.Serialize(writer, o);
                XmlDocument xml = new XmlDocument();
                string xmlString = ASCIIEncoding.UTF8.GetString(ms.ToArray());
                xml.LoadXml(xmlString);
                return xml;
            }
            catch (Exception e)
            {
                caught = e;
            }
            finally
            {
                writer.Close();
                ms.Close();

                if (caught != null)
                    throw caught;
            }
            return null;
        }

        /// <summary>
        /// Makes a PUT call to Xero to update data at Zero.
        /// </summary>
        /// <param name="obj"></param>
        /// <param name="url"></param>
        /// <param name="outputType"></param>
        /// <returns></returns>
        private object PutObject(Object obj, string url, Type outputType, string[] elementToRemove)
        {
            // set up the request
            byte[] data = GetBytes(obj, elementToRemove);
            string s = System.Text.ASCIIEncoding.ASCII.GetString(data);

            try
            {
                // create and initialize the web request
                HttpWebRequest request = CreateWebRequest(url, "PUT");
                // set the content length in the request headers
                request.ContentLength = data.Length;
                // write the data to the imported into the request stream
                using (Stream requestStream = request.GetRequestStream())
                {
                    requestStream.Write(data, 0, data.Length);
                }
                // get the response
                return GetResponseFromHttpRequest(request, outputType);
            }
            finally
            {

            }
        }

        /// <summary>
        /// Makes the HTTP Request to Xero and parses the response.
        /// </summary>
        /// <param name="request"></param>
        /// <param name="outputType"></param>
        /// <returns></returns>
        private object GetResponseFromHttpRequest(HttpWebRequest request, Type outputType)
        {
            try
            {
                using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
                {
                    if (request.HaveResponse == true && response != null)
                    {
                        if (response.StatusCode == HttpStatusCode.Unauthorized)
                        {
                            throw new Exception("401, API Key is wrong");
                        }
                        // get the response stream and read it
                        using (StreamReader reader = new StreamReader(response.GetResponseStream()))
                        {
                            if (outputType != null)
                            {   // Parse the XML into an object
                                XmlSerializer serializer = new XmlSerializer(outputType);
                                object res = serializer.Deserialize(reader);
                                return res;
                            }
                        }
                    }
                }
                throw new Exception("No result returned from call");
            }
            catch (WebException wex)
            {
                // this exception will be raised if the server didn't return 200 (OK)
                // some requests cause the import API to return status codes such as 401 and 500
                // it is important to retrieve more information about any particular errors
                string errorMessage = wex.ToString();
                if (wex.Response != null)
                {
                    // get the error response
                    using (HttpWebResponse errorResponse = (HttpWebResponse)wex.Response)
                    {
                        errorMessage = string.Format("The server returned '{0}' with the status code {1} ({2:d}).",
                                errorResponse.StatusDescription, errorResponse.StatusCode, errorResponse.StatusCode);
                        // get the error response stream and read it
                        using (StreamReader reader = new StreamReader(errorResponse.GetResponseStream()))
                        {
                            string responseText = reader.ReadToEnd();
                            errorMessage = errorMessage + ", Response Body Was: " + responseText;                            
                        }
                    }
                }
                throw new Exception(errorMessage);
            }
        }

        /// <summary>
        /// Makes a Get request to Xero
        /// </summary>
        /// <param name="t"></param>
        /// <param name="url"></param>
        /// <returns></returns>
        private object GetObjectFromURL(Type t, string url)
        {
            HttpWebRequest request = CreateWebRequest(url, "GET");
            return GetResponseFromHttpRequest(request, t);
        }

        /// <summary>
        /// Gets the parameters that are required for all API calls
        /// </summary>
        /// <returns></returns>
        private string GetBaseUrlParameters()
        {
            return "apiKey=" + apiKey + "&xeroKey=" + customerKey;
        }

        /// <summary>
        /// The code uses the same Invoice and Contact classes for fetching and updating.  Xero does not allow some fields
        /// to be updated and throws errors if they are passed.  This method will remove these elements from the XML.
        /// </summary>
        /// <param name="doc"></param>
        /// <param name="tagName"></param>
        private void RemoveElements(XmlDocument doc, string tagName)
        {
            XmlNodeList list = doc.GetElementsByTagName(tagName);
            if (list != null)
            {
                List<XmlNode> nodes = new List<XmlNode>();
                foreach (XmlNode n in list)
                {
                    nodes.Add(n);
                }
                foreach (XmlNode n in nodes)
                {
                    n.ParentNode.RemoveChild(n);
                }
            }
        }

        /// <summary>
        /// Serializes an XML annotated class to a byte array
        /// </summary>
        /// <param name="obj"></param>
        /// <returns></returns>
        private byte[] GetBytes(object obj, string[] elementToRemove)
        {
            XmlDocument doc = Serialize(obj);
            if (elementToRemove != null)
            {
                foreach (string tagName in elementToRemove)
                {
                    RemoveElements(doc, tagName);
                }
            }
            StringWriter stringWriter = new StringWriter();
            XmlTextWriter xmlTextWriter = new XmlTextWriter(stringWriter);
            xmlTextWriter.Formatting = Formatting.Indented;
            doc.WriteTo(xmlTextWriter); //xmlDocument can be replaced with an XmlNode
            xmlTextWriter.Flush();
            return System.Text.ASCIIEncoding.ASCII.GetBytes(stringWriter.ToString());
        }

        /// <summary>
        /// Checks that the response to the Xero call was ok and throws an exception if not
        /// </summary>
        /// <param name="res"></param>
        private void CheckResponse(IXeroResponse res)
        {
            if (res.Status != "OK")
            {
                StringBuilder sb = new StringBuilder();
                List<Error> errors = res.Errors;
                if (errors != null)
                {
                    foreach (Error err in errors)
                    {
                        sb.Append (err.Description);
                        sb.Append(", ");
                    }
                }
                throw new Exception("Sorry there was a problem " + res.Status + ", errors=" + sb.ToString());
            }
        }
    }
}
